/*******************************************************************************
 * This file is part of Crunch Network.
 *
 * Crunch Network is free software: you can redistribute it and/or modify it under the
 * terms of the GNU Lesser General Public License as published by the Free Software
 * Foundation, either version 3 of the License, or (at your option) any later version.
 *
 * Crunch Network is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
 * PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License along with
 * Crunch Network.  If not, see <http://www.gnu.org/licenses/>.
 ******************************************************************************/

package com.crunch.network;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.HashMap;
import java.util.Iterator;

/**
 * Manages connections on the network.
 */
class NetworkConnectionManager {
	/**
	 * The constructor sets the state to uninitialized.
	 */
	public NetworkConnectionManager() {
		initialized = false;
	}

	/**
	 * Initializes the network connection manager with the given settings.
	 *
	 * @param   settings the network settings.
	 * @throws  IllegalStateException if the network connection manager is already initialized.
	 * @throws  IllegalArgumentException if {@code settings} contains invalid network settings (e.g. a negative port
	 *          number). See {@link NetworkSettings} for details.
	 * @throws  IOException if a socket cannot be opened on the specified port.
	 */
	public void initialize(NetworkSettings settings)
			throws IOException {
		if (initialized) {
			throw new IllegalStateException("Network connection manager already initialized");
		}

		if (!settings.checkSettings()) {
			throw new IllegalArgumentException("Invalid network settings");
		}

		this.settings = settings;

		socket = new NetworkSocket();
		try {
			socket.open(settings.port);
		} catch (IOException e) {
			socket = null;
			throw e;
		}

		packet = new NetworkPacket(settings);

		connections = new HashMap<Integer, NetworkConnection>();
		addresses = new HashMap<NetworkAddress, NetworkConnection>();
		nextConnectionId = 0;

		initialized = true;
	}

	/**
	 * Returns whether the network connection manager is initialized.
	 *
	 * @return  whether the network connection manager is initialized.
	 */
	public boolean isInitialized() {
		return initialized;
	}

	/**
	 * Terminates the network connection manager. All existing connections are closed.
	 */
	public void terminate() {
		if (!initialized) {
			return;
		}

		socket.close();
		socket = null;
		packet = null;
		connections = null;
		addresses = null;
		initialized = false;
	}

	/**
	 * Creates a new {@link com.crunch.network.NetworkConnection} for the given {@link NetworkAddress}. When explicitly
	 * creating connections with this method, {@link NetworkCallbacks#onReceiveNewConnection(NetworkConnection, long)}
	 * is not called. Note that the other end is not aware of this connection until it receives data or explicitly
	 * creates its own connection to this device.
	 *
	 * @param   address the address to connect to.
	 * @return  a {@link NetworkConnection} instance representing the new connection.
	 * @throws  IllegalStateException if not yet initialized.
	 */
	public NetworkConnection createConnection(NetworkAddress address) {
		if (!initialized) {
			throw new IllegalStateException("Network connection manager not initialized");
		}

		long currentTimeMs = settings.callbacks.getCurrentTimeMs();

		int connectionId = nextConnectionId;
		++nextConnectionId;
		NetworkConnection connection = new NetworkConnection(settings, connectionId, address, currentTimeMs);
		connections.put(connectionId, connection);
		addresses.put(new NetworkAddress(address), connection);
		return connection;
	}

	/**
	 * Destroys the connection with the given ID. The ID of a connection can be obtained by calling {@link
	 * NetworkConnection#getConnectionId()}. Note that this method does not alert the other end that the connection is
	 * being destroyed.
	 *
	 * @param   connectionId the ID of the connection to destroy.
	 * @return  whether the connection was destroyed.
	 * @throws  IllegalStateException if not yet initialized.
	 */
	public boolean destroyConnection(int connectionId) {
		if (!initialized) {
			throw new IllegalStateException("Network connection manager not initialized");
		}

		NetworkConnection connection = connections.remove(connectionId);
		if (connection == null) {
			settings.callbacks.log(NetworkCallbacks.LogLevel.WARNING, "Attempted to destroy invalid connection");
		} else {
			addresses.remove(connection.getNetworkAddress());
		}

		return (connection != null);
	}

	/**
	 * Returns the connection with the given ID. If no such connection exists, {@code null} is returned.
	 *
	 * @param   connectionId the ID of the connection which should be returned.
	 * @return  the connection associated with the given ID.
	 */
	public NetworkConnection getConnection(int connectionId) {
		return connections.get(connectionId);
	}

	/**
	 * Receives packets on all connections and updates network statistics.
	 *
	 * @throws  IllegalStateException if not yet initialized.
	 */
	public void receiveAndProcess() {
		if (!initialized) {
			throw new IllegalStateException("Network connection manager not initialized");
		}

		// get the current time
		long currentTimeMs = settings.callbacks.getCurrentTimeMs();

		while (true) {
			NetworkAddress sourceAddress = null;
			ByteBuffer packetBuffer = ByteBuffer.wrap(packet.getPacketData());
			try {
				sourceAddress = socket.receive(packetBuffer, packet.getPacketDataSize());
			} catch (IOException e) {
				settings.callbacks.log(NetworkCallbacks.LogLevel.ERROR, e.getMessage());
			}
			if (sourceAddress == null) {
				break;
			}

			int packetDataSize = packetBuffer.position();

			// make sure the packet is valid
			if (!packet.isPacketDataSizeValid(packetDataSize)) {
				settings.callbacks.log(NetworkCallbacks.LogLevel.WARNING, "Invalid packet received");
				continue;
			}
			packet.setPacketContentSize(packetDataSize - packet.getPacketHeaderSize());

			// check the protocol ID
			if (packet.getProtocolId() != settings.protocolId) {
				settings.callbacks.log(NetworkCallbacks.LogLevel.WARNING, "Packet with invalid protocol ID received");
				continue;
			}

			// check if this is a new connection
			NetworkConnection connection = addresses.get(sourceAddress);
			if (connection == null) {
				// add the connection so that we get an ID back
				// if we decide not to keep it, remove the connection
				connection = createConnection(sourceAddress);

				boolean keep = settings.callbacks.onReceiveNewConnection(connection, currentTimeMs);
				if (!keep) {
					// discard the new connection
					destroyConnection(connection.getConnectionId());
					// continue receiving other packets
					continue;
				}
			}

			// now we process the data
			connection.receivePacket(packet, currentTimeMs);
		}

		// we now do some processing on each connection
		Iterator<NetworkConnection> it = connections.values().iterator();
		while (it.hasNext()) {
			// first, time out connections that are too old
			NetworkConnection connection = it.next();
			long timeSinceCreation = currentTimeMs - connection.getCreationTimeMs();
			long timeSinceLastPacket = currentTimeMs - connection.getLastReceiveTimeMs();
			// take time since creation into account too
			// if we don't do this, manually created connections would be immediately destroyed
			if (timeSinceCreation > settings.connectionTimeoutMs && timeSinceLastPacket > settings.connectionTimeoutMs) {
				// call the timed out function
				settings.callbacks.onConnectionTimedOut(connection, currentTimeMs);
				// remove the connection
				it.remove();
				continue;
			}

			// calculate data rates
			connection.calculateDataRates(currentTimeMs);
		}
	}

	/**
	 * Sends a message on the connection with the given ID.
	 *
	 * @param   connectionId the ID of the connection to send the data on.
	 * @param   data buffer containing the data to send. The buffer's limit specifies the size of the data.
	 * @return  an ID for the outgoing packet used to uniquely identify this packet, or -1 if the packet failed to send.
	 * @throws  IllegalStateException if not yet initialized.
	 * @throws  IllegalArgumentException if an invalid connection ID is provided.
	 */
	public int send(int connectionId, ByteBuffer data) {
		if (!initialized) {
			throw new IllegalStateException("Network connection manager not initialized");
		}

		NetworkConnection connection = connections.get(connectionId);
		if (connection == null) {
			throw new IllegalArgumentException("Invalid connection ID");
		}

		// check for too much data
		int packetSize = data.remaining();
		if (packetSize > packet.getMaxPacketContentSize()) {
			throw new IllegalArgumentException("Packet data too large");
		}

		long currentTimeMs = settings.callbacks.getCurrentTimeMs();

		// set the packet's content
		ByteBuffer destBuffer = ByteBuffer.wrap(packet.getPacketData(), packet.getPacketContentOffset(), packet.getMaxPacketContentSize());
		destBuffer.put(data);
		packet.setPacketContentSize(packetSize);
		// let the connection know we're sending a packet (the connection will modify the header)
		connection.sendPacket(packet, currentTimeMs);
		int packetId = connection.getLocalPacketId(packet);
		try {
			// send the packet and return whether something went wrong
			socket.send(connection.getNetworkAddress(), ByteBuffer.wrap(packet.getPacketData(), 0, packet.getPacketDataSize()));
		} catch (IOException e) {
			settings.callbacks.log(NetworkCallbacks.LogLevel.ERROR, e.getMessage());
			return -1;
		}
		return packetId;
	}

	private boolean initialized;        // whether the NetworkConnectionManager is initialized
	private NetworkSettings settings;   // the settings
	private NetworkSocket socket;       // the socket all connections use
	private NetworkPacket packet;       // the packet which data is read from and written to

	// map associating connection IDs with connections
	private HashMap<Integer, NetworkConnection> connections;
	// map associating connection addresses with connections
	private HashMap<NetworkAddress, NetworkConnection> addresses;
	// ID of the next connection
	private int nextConnectionId;
}
